Français

Un guide complet sur les génériques TypeScript, couvrant leur syntaxe, leurs avantages, leur utilisation avancée et les meilleures pratiques pour gérer des types de données complexes dans le développement logiciel mondial.

Génériques TypeScript : Maîtriser les types de données complexes pour des applications robustes

TypeScript, un sur-ensemble de JavaScript, permet aux développeurs d'écrire du code plus robuste et maintenable grâce au typage statique. Parmi ses fonctionnalités les plus puissantes se trouvent les génériques, qui vous permettent d'écrire du code pouvant fonctionner avec une variété de types de données tout en maintenant la sécurité des types. Ce guide propose une exploration complète des génériques TypeScript, en se concentrant sur leur application aux types de données complexes dans le contexte du développement logiciel mondial.

Que sont les génériques ?

Les génériques offrent un moyen d'écrire du code réutilisable qui peut fonctionner avec différents types. Au lieu d'écrire des fonctions ou des classes distinctes pour chaque type que vous souhaitez prendre en charge, vous pouvez écrire une seule fonction ou classe qui utilise des paramètres de type. Ces paramètres de type sont des espaces réservés pour les types réels qui seront utilisés lorsque la fonction ou la classe est appelée ou instanciée. Ceci est particulièrement utile lorsqu'on traite des structures de données complexes où le type de données au sein de ces structures peut varier.

Avantages de l'utilisation des génériques

Syntaxe de base des génériques

La syntaxe de base des génériques implique l'utilisation de chevrons (< >) pour déclarer des paramètres de type. Ces paramètres de type sont généralement nommés T, K, V, etc., mais vous pouvez utiliser n'importe quel identifiant valide. Voici un exemple simple d'une fonction générique :


function identity<T>(arg: T): T {
  return arg;
}

let myString: string = identity<string>("hello");
let myNumber: number = identity<number>(123);
let myBoolean: boolean = identity<boolean>(true);

console.log(myString); // Sortie : hello
console.log(myNumber); // Sortie : 123
console.log(myBoolean); // Sortie : true

Dans cet exemple, <T> déclare un paramètre de type nommé T. La fonction identity prend un argument de type T et renvoie une valeur de type T. En appelant la fonction, vous pouvez spécifier explicitement le paramètre de type (par exemple, identity<string>) ou laisser TypeScript l'inférer en fonction du type de l'argument.

Travailler avec des types de données complexes

Les génériques deviennent particulièrement précieux lorsqu'il s'agit de types de données complexes tels que les tableaux, les objets et les interfaces. Explorons quelques scénarios courants :

Tableaux génériques

Vous pouvez utiliser des génériques pour créer des fonctions ou des classes qui fonctionnent avec des tableaux de différents types :


function arrayToString<T>(arr: T[]): string {
  return arr.join(", ");
}

let numberArray: number[] = [1, 2, 3, 4, 5];
let stringArray: string[] = ["apple", "banana", "cherry"];

console.log(arrayToString(numberArray)); // Sortie : 1, 2, 3, 4, 5
console.log(arrayToString(stringArray)); // Sortie : apple, banana, cherry

Ici, la fonction arrayToString prend un tableau de type T[] et renvoie une représentation en chaîne de caractères du tableau. Cette fonction fonctionne avec des tableaux de n'importe quel type, ce qui la rend hautement réutilisable.

Objets génériques

Les génériques peuvent également être utilisés pour définir des fonctions ou des classes qui fonctionnent avec des objets de différentes formes :


interface Person {
  name: string;
  age: number;
  country: string; // Ajout du pays pour le contexte mondial
}

interface Product {
  id: number;
  name: string;
  price: number;
  currency: string; // Ajout de la devise pour le contexte mondial
}

function displayInfo<T extends { name: string }>(item: T): void {
  console.log(`Nom : ${item.name}`);
}

let person: Person = { name: "Alice", age: 30, country: "USA" };
let product: Product = { id: 1, name: "Laptop", price: 1200, currency: "USD" };

displayInfo(person); // Sortie : Nom : Alice
displayInfo(product); // Sortie : Nom : Laptop

Dans cet exemple, la fonction displayInfo prend un objet de type T qui doit avoir une propriété name de type chaîne de caractères. La clause extends { name: string } est une contrainte, qui spécifie les exigences minimales pour le paramètre de type T. Cela garantit que la fonction peut accéder en toute sécurité à la propriété name.

Utilisation avancée des génériques

Les génériques TypeScript offrent des fonctionnalités plus avancées qui vous permettent de créer un code encore plus flexible et puissant. Explorons certaines de ces fonctionnalités :

Paramètres de type multiples

Vous pouvez définir des fonctions ou des classes avec plusieurs paramètres de type :


function merge<T, U>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

interface Name {
  firstName: string;
}

interface Age {
  age: number;
}

const person: Name = { firstName: "Bob" };
const details: Age = { age: 42 };

const merged = merge(person, details);
console.log(merged.firstName); // Sortie : Bob
console.log(merged.age); // Sortie : 42

La fonction merge prend deux objets de types T et U et renvoie un nouvel objet qui contient les propriétés des deux objets. C'est un moyen puissant de combiner des données de différentes sources.

Contraintes génériques

Comme montré précédemment, les contraintes vous permettent de restreindre les types qui peuvent être utilisés avec un paramètre de type générique. Cela garantit que le code générique peut fonctionner en toute sécurité sur les types spécifiés.


interface Lengthwise {
  length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length);
  return arg;
}

loggingIdentity([1, 2, 3]); // Sortie : 3
loggingIdentity("hello"); // Sortie : 5
// loggingIdentity(123); // Erreur : L'argument de type 'number' n'est pas assignable au paramètre de type 'Lengthwise'.

La fonction loggingIdentity prend un argument de type T qui doit avoir une propriété length de type nombre. Cela garantit que la fonction peut accéder en toute sécurité à la propriété length.

Classes génériques

Les génériques peuvent également être utilisés avec les classes :


class DataStorage<T> {
  private data: T[] = [];

  addItem(item: T) {
    this.data.push(item);
  }

  removeItem(item: T) {
    this.data = this.data.filter(d => d !== item);
  }

  getItems(): T[] {
    return [...this.data];
  }
}

const textStorage = new DataStorage<string>();
textStorage.addItem("apple");
textStorage.addItem("banana");
textStorage.removeItem("apple");
console.log(textStorage.getItems()); // Sortie : [ 'banana' ]

const numberStorage = new DataStorage<number>();
numberStorage.addItem(1);
numberStorage.addItem(2);
numberStorage.removeItem(1);
console.log(numberStorage.getItems()); // Sortie : [ 2 ]

La classe DataStorage peut stocker des données de n'importe quel type T. Cela vous permet de créer des structures de données réutilisables qui sont typées en toute sécurité.

Interfaces génériques

Les interfaces génériques sont utiles pour définir des contrats qui peuvent fonctionner avec différents types. Par exemple :


interface Result<T, E> {
  success: boolean;
  data?: T;
  error?: E;
}

interface User {
  id: number;
  username: string;
  email: string;
}

interface ErrorMessage {
  code: number;
  message: string;
}

function fetchUser(id: number): Result<User, ErrorMessage> {
  if (id === 1) {
    return { success: true, data: { id: 1, username: "john.doe", email: "john.doe@example.com" } };
  } else {
    return { success: false, error: { code: 404, message: "Utilisateur non trouvé" } };
  }
}

const userResult = fetchUser(1);
if (userResult.success) {
  console.log(userResult.data.username);
} else {
  console.log(userResult.error.message);
}

L'interface Result définit une structure générique pour représenter le résultat d'une opération. Elle peut contenir soit des données de type T, soit une erreur de type E. C'est un modèle courant pour gérer les opérations asynchrones ou les opérations qui peuvent échouer.

Types utilitaires et génériques

TypeScript fournit plusieurs types utilitaires intégrés qui fonctionnent bien avec les génériques. Ces types utilitaires peuvent vous aider à transformer et à manipuler les types de manière puissante.

Partial<T>

Partial<T> rend toutes les propriétés du type T optionnelles :


interface Person {
  name: string;
  age: number;
}

type PartialPerson = Partial<Person>;

const partialPerson: PartialPerson = { name: "Alice" }; // Valide

Readonly<T>

Readonly<T> rend toutes les propriétés du type T en lecture seule :


interface Person {
  name: string;
  age: number;
}

type ReadonlyPerson = Readonly<Person>;

const readonlyPerson: ReadonlyPerson = { name: "Bob", age: 42 };
// readonlyPerson.age = 43; // Erreur : Impossible d'assigner à 'age' car c'est une propriété en lecture seule.

Pick<T, K>

Pick<T, K> sélectionne un ensemble de propriétés K du type T :


interface Person {
  name: string;
  age: number;
  email: string;
}

type NameAndAge = Pick<Person, "name" | "age">;

const nameAndAge: NameAndAge = { name: "Charlie", age: 28 };

Omit<T, K>

Omit<T, K> supprime un ensemble de propriétés K du type T :


interface Person {
  name: string;
  age: number;
  email: string;
}

type PersonWithoutEmail = Omit<Person, "email">;

const personWithoutEmail: PersonWithoutEmail = { name: "David", age: 35 };

Record<K, T>

Record<K, T> crée un type avec des clés K et des valeurs de type T :


type CountryCodes = "US" | "CA" | "UK" | "DE" | "FR" | "JP" | "CN" | "IN" | "BR" | "AU"; // Liste étendue pour le contexte mondial
type Currency = "USD" | "CAD" | "GBP" | "EUR" | "JPY" | "CNY" | "INR" | "BRL" | "AUD"; // Liste étendue pour le contexte mondial

type CurrencyMap = Record<CountryCodes, Currency>;

const currencyMap: CurrencyMap = {
  "US": "USD",
  "CA": "CAD",
  "UK": "GBP",
  "DE": "EUR",
  "FR": "EUR",
  "JP": "JPY",
  "CN": "CNY",
  "IN": "INR",
  "BR": "BRL",
  "AU": "AUD",
};

Types mappés

Les types mappés vous permettent de transformer des types existants en itérant sur leurs propriétés. C'est un moyen puissant de créer de nouveaux types basés sur des types existants. Par exemple, vous pouvez créer un type qui rend toutes les propriétés d'un autre type en lecture seule :


interface Person {
  name: string;
  age: number;
}

type ReadonlyPerson = {
  readonly [K in keyof Person]: Person[K];
};

const readonlyPerson: ReadonlyPerson = { name: "Eve", age: 25 };
// readonlyPerson.age = 26; // Erreur : Impossible d'assigner à 'age' car c'est une propriété en lecture seule.

Dans cet exemple, [K in keyof Person] itère sur toutes les clés de l'interface Person, et Person[K] accède au type de chaque propriété. Le mot-clé readonly rend chaque propriété en lecture seule.

Types conditionnels

Les types conditionnels vous permettent de définir des types basés sur des conditions. C'est un moyen puissant de créer des types qui s'adaptent à différents scénarios.


type NonNullable<T> = T extends null | undefined ? never : T;

type MaybeString = string | null | undefined;
type StringType = NonNullable<MaybeString>; // string

function getValue<T>(value: T): NonNullable<T> {
  if (value == null) { // Gère à la fois null et undefined
    throw new Error("La valeur ne peut pas être nulle ou indéfinie");
  }
  return value as NonNullable<T>;
}

try {
  const validValue = getValue("hello");
  console.log(validValue.toUpperCase()); // Sortie : HELLO

  const invalidValue = getValue(null); // Ceci lèvera une erreur
  console.log(invalidValue); // Cette ligne ne sera pas atteinte
} catch (error: any) {
  console.error(error.message); // Sortie : La valeur ne peut pas être nulle ou indéfinie
}

Dans cet exemple, le type NonNullable<T> vérifie si T est null ou undefined. Si c'est le cas, il renvoie never, ce qui signifie que le type n'est pas autorisé. Sinon, il renvoie T. Cela vous permet de créer des types qui sont garantis de ne pas être nuls.

Meilleures pratiques pour l'utilisation des génériques

Voici quelques meilleures pratiques à garder à l'esprit lors de l'utilisation des génériques :

Exemples dans un contexte mondial

Considérons quelques exemples de la manière dont les génériques peuvent être utilisés dans un contexte mondial :

Conversion de devises


interface ConversionRate {
  rate: number;
  fromCurrency: string;
  toCurrency: string;
}

function convertCurrency<T extends ConversionRate>(amount: number, rate: T): number {
  return amount * rate.rate;
}

const usdToEurRate: ConversionRate = { rate: 0.85, fromCurrency: "USD", toCurrency: "EUR" };
const amountInUSD = 100;
const amountInEUR = convertCurrency(amountInUSD, usdToEurRate);
console.log(`${amountInUSD} USD équivalent à ${amountInEUR} EUR`); // Sortie : 100 USD équivalent à 85 EUR

Formatage de date


interface DateFormatOptions {
  locale: string;
  options: Intl.DateTimeFormatOptions;
}

function formatDate<T extends DateFormatOptions>(date: Date, format: T): string {
  return date.toLocaleDateString(format.locale, format.options);
}

const currentDate = new Date();

const usDateFormat: DateFormatOptions = { locale: "en-US", options: { year: 'numeric', month: 'long', day: 'numeric' } };
const germanDateFormat: DateFormatOptions = { locale: "de-DE", options: { year: 'numeric', month: 'long', day: 'numeric' } };
const japaneseDateFormat: DateFormatOptions = { locale: "ja-JP", options: { year: 'numeric', month: 'long', day: 'numeric' } };

console.log("Date US : " + formatDate(currentDate, usDateFormat));
console.log("Date allemande : " + formatDate(currentDate, germanDateFormat));
console.log("Date japonaise : " + formatDate(currentDate, japaneseDateFormat));

Service de traduction


interface Translation {
  [key: string]: string; // Permet des clés de langue dynamiques
}

interface LanguageData<T extends Translation> {
  languageCode: string;
  translations: T;
}

const englishTranslations: Translation = {
  "hello": "Hello",
  "goodbye": "Goodbye",
  "welcome": "Welcome to our website!"
};

const spanishTranslations: Translation = {
  "hello": "Hola",
  "goodbye": "Adiós",
  "welcome": "¡Bienvenido a nuestro sitio web!"
};

const frenchTranslations: Translation = {
  "hello": "Bonjour",
  "goodbye": "Au revoir",
  "welcome": "Bienvenue sur notre site web !"
};


const languageData: LanguageData<typeof englishTranslations>[] = [
  {languageCode: "en", translations: englishTranslations },
  {languageCode: "es", translations: spanishTranslations },
  {languageCode: "fr", translations: frenchTranslations}
];

function translate<T extends Translation>(key: string, languageCode: string, languageData: LanguageData<T>[]): string {
  const lang = languageData.find(lang => lang.languageCode === languageCode);
  if (!lang) {
    return `Traduction pour ${key} en ${languageCode} non trouvée.`;
  }
  return lang.translations[key] || `Traduction pour ${key} non trouvée.`;
}

console.log(translate("hello", "en", languageData)); // Sortie : Hello
console.log(translate("hello", "es", languageData)); // Sortie : Hola
console.log(translate("welcome", "fr", languageData)); // Sortie : Bienvenue sur notre site web !
console.log(translate("missingKey", "de", languageData)); // Sortie : Traduction pour missingKey en de non trouvée.

Conclusion

Les génériques TypeScript sont un outil puissant pour écrire du code réutilisable et typé en toute sécurité, capable de fonctionner avec des types de données complexes. En comprenant la syntaxe de base, les fonctionnalités avancées et les meilleures pratiques des génériques, vous pouvez améliorer considérablement la qualité et la maintenabilité de vos applications TypeScript. Lors du développement d'applications pour un public mondial, les génériques peuvent vous aider à gérer divers formats de données et conventions culturelles, garantissant une expérience utilisateur transparente pour tous.